/**
* CentralMap.java
* Copyright (C)2015 Nicholas Killewald
*
* This file is distributed under the terms of the BSD license.
* The source package should have a LICENSE file at the toplevel.
*/
package net.exclaimindustries.geohashdroid.activities;
import android.annotation.SuppressLint;
import android.app.backup.BackupManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.location.Location;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Parcelable;
import android.preference.PreferenceManager;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.StringRes;
import android.support.v4.content.ContextCompat;
import android.util.Log;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.Toast;
import com.commonsware.cwac.wakeful.WakefulIntentService;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.location.LocationListener;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.location.LocationServices;
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.MapFragment;
import com.google.android.gms.maps.OnMapReadyCallback;
import com.google.android.gms.maps.UiSettings;
import com.google.android.gms.maps.model.BitmapDescriptorFactory;
import com.google.android.gms.maps.model.Marker;
import com.google.android.gms.maps.model.MarkerOptions;
import net.exclaimindustries.geohashdroid.R;
import net.exclaimindustries.geohashdroid.fragments.AboutDialogFragment;
import net.exclaimindustries.geohashdroid.fragments.GHDDatePickerDialogFragment;
import net.exclaimindustries.geohashdroid.fragments.MapTypeDialogFragment;
import net.exclaimindustries.geohashdroid.fragments.PermissionDeniedDialogFragment;
import net.exclaimindustries.geohashdroid.fragments.VersionHistoryDialogFragment;
import net.exclaimindustries.geohashdroid.services.AlarmService;
import net.exclaimindustries.geohashdroid.services.StockService;
import net.exclaimindustries.geohashdroid.util.ExpeditionMode;
import net.exclaimindustries.geohashdroid.util.GHDConstants;
import net.exclaimindustries.geohashdroid.util.Graticule;
import net.exclaimindustries.geohashdroid.util.Info;
import net.exclaimindustries.geohashdroid.util.KnownLocation;
import net.exclaimindustries.geohashdroid.util.PermissionsDeniedListener;
import net.exclaimindustries.geohashdroid.util.SelectAGraticuleMode;
import net.exclaimindustries.geohashdroid.util.UnitConverter;
import net.exclaimindustries.geohashdroid.util.VersionHistoryParser;
import net.exclaimindustries.geohashdroid.widgets.ErrorBanner;
import net.exclaimindustries.tools.DateTools;
import net.exclaimindustries.tools.LocationUtil;
import org.xmlpull.v1.XmlPullParserException;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
/**
* CentralMap replaces MainMap as the map display. Unlike MainMap, it also
* serves as the entry point for the entire app. These comments are going to
* make so much sense later when MainMap is little more than a class that only
* exists on the legacy branch.
*/
public class CentralMap
extends BaseMapActivity
implements GoogleApiClient.ConnectionCallbacks,
GoogleApiClient.OnConnectionFailedListener,
GHDDatePickerDialogFragment.GHDDatePickerCallback {
private static final String DEBUG_TAG = "CentralMap";
private static final String DATE_PICKER_DIALOG = "datePicker";
private static final String MAP_TYPE_DIALOG = "mapType";
private static final String VERSION_HISTORY_DIALOG = "versionHistory";
private static final String ABOUT_DIALOG = "about";
private static final String STATE_WAS_ALREADY_ZOOMED = "alreadyZoomed";
private static final String STATE_WAS_SELECT_A_GRATICULE = "selectAGraticule";
private static final String STATE_WAS_GLOBALHASH = "globalhash";
private static final String STATE_WAS_RESOLVING_CONNECTION_ERROR = "resolvingError";
private static final String STATE_WERE_PERMISSIONS_DENIED = "permissionsDenied";
private static final String STATE_LAST_GRATICULE = "lastGraticule";
private static final String STATE_LAST_CALENDAR = "lastCalendar";
private static final String STATE_LAST_MODE_BUNDLE = "lastModeBundle";
private static final int LOCATION_PERMISSION_REQUEST = 1;
public static final String ACTION_START_CLOSEST_HASHPOINT = "net.exclaimindustries.geohashdroid.START_CLOSEST_HASHPOINT";
public static final String ACTION_START_LAST_USED = "net.exclaimindustries.geohashdroid.START_LAST_USED";
public static final String ACTION_START_GRATICULE_PICKER = "net.exclaimindustries.geohashdroid.START_GRATICULE_PICKER";
// If we're in Select-A-Graticule mode (as opposed to expedition mode).
private boolean mSelectAGraticule = false;
// If we already did the initial zoom for this expedition.
private boolean mAlreadyDidInitialZoom = false;
// If the map's ready.
private boolean mMapIsReady = false;
private GoogleApiClient mGoogleClient;
private Location mLastKnownLocation;
// This is either the current expedition Graticule (same as in mCurrentInfo)
// or the last-selected Graticule in Select-A-Graticule mode (needed if we
// need to reconstruct from an onDestroy()).
private Graticule mLastGraticule;
private Calendar mLastCalendar;
// Because a null Graticule is considered to be the Globalhash indicator, we
// need a boolean to keep track of whether we're actually in a Globalhash or
// if we just don't have a Graticule yet.
private boolean mGlobalhash;
private ErrorBanner mBanner;
private View mProgress;
private Bundle mLastModeBundle;
private CentralMapMode mCurrentMode;
private float mProgressHeight = 0.0f;
private List<Marker> mKnownLocationMarkers;
/**
* <p>
* A <code>CentralMapMode</code> is a set of behaviors that happen whenever
* some corresponding event occurs in {@link CentralMap}.
* </p>
*
* <p>
* Note the {@link #pause()} and {@link #resume()} methods. While those
* correspond to {@link CentralMap}'s onPause and onResume methods, there is
* NOT a similar lifecycle in <code>CentralMapMode</code>. That is,
* {@link #pause()} and {@link #resume()} are NOT guaranteed to be called in
* any relation to {@link #init(Bundle)} or {@link #cleanUp()}. If there's
* never an onPause or onResume in the life of a CentralMapMode, it will NOT
* receive the corresponding calls, and will instead just get the
* {@link #init(Bundle)} and {@link #cleanUp()} calls.
* </p>
*/
public abstract static class CentralMapMode implements LocationListener,
PermissionsDeniedListener {
protected boolean mInitComplete = false;
private boolean mCleanedUp = false;
/** Flag indicating a handleInfo call comes from a notification. */
protected final static int FLAG_FROM_NOTIFICATION = 0x1000000;
/** Bundle key for the current Graticule. */
public final static String GRATICULE = "graticule";
/** Bundle key for the current date, as a Calendar. */
public final static String CALENDAR = "calendar";
/**
* Bundle key for a boolean indicating that, if the Graticule is null,
* this was actually a Globalhash, not just a request with an empty
* Graticule.
*/
public final static String GLOBALHASH = "globalhash";
/**
* Bundle key for the current Info. In cases where this can be given,
* the Graticule, Calendar, and boolean indicating a Globalhash can be
* implied from it.
*/
public final static String INFO = "info";
/** The current GoogleMap object. */
protected GoogleMap mMap;
/** The calling CentralMap Activity. */
protected CentralMap mCentralMap;
/** The current destination Marker. */
protected Marker mDestination;
/**
* Sets the {@link GoogleMap} this mode deals with. When implementing
* this, make sure to actually do something with it like subscribe to
* events as the mode needs them if you're not doing so in
* {@link #init(Bundle)}.
*
* @param map that map
*/
public void setMap(@NonNull GoogleMap map) {
mMap = map;
}
/**
* Sets the {@link CentralMap} to which this will talk back.
*
* @param centralMap that CentralMap
*/
public void setCentralMap(@NonNull CentralMap centralMap) {
mCentralMap = centralMap;
}
/**
* Gets the current GoogleApiClient held by CentralMap. This will
* return null if the client isn't usable (not connected, null itself,
* etc).
*
* @return the current GoogleApiClient
*/
@Nullable
protected final GoogleApiClient getGoogleClient() {
if(mCentralMap != null) {
GoogleApiClient gClient = mCentralMap.getGoogleClient();
if(gClient != null && gClient.isConnected())
return gClient;
else
return null;
} else {
return null;
}
}
/**
* <p>
* Does whatever init tomfoolery is needed for this class, using the
* given Bundle of stuff. You're probably best calling this AFTER
* {@link #setMap(GoogleMap)} and {@link #setCentralMap(CentralMap)} are
* called and when the GoogleApiClient object is ready for use.
* </p>
*
* @param bundle a bunch of stuff, or null if there's no stuff to be had
*/
public abstract void init(@Nullable Bundle bundle);
/**
* Does whatever cleanup rigmarole is needed for this class, such as
* unsubscribing to all those subscriptions you set up in {@link #setMap(GoogleMap)}
* or {@link #init(Bundle)}.
*/
public void cleanUp() {
// The marker always goes away, at the very least.
removeDestinationPoint();
if(mCentralMap != null) mCentralMap.getErrorBanner().animateBanner(false);
// Set the cleaned up flag, too.
mCleanedUp = true;
}
/**
* Stores the state of this mode into yonder Bundle. This is NOT
* guaranteed to be followed by {@link #cleanUp()}, apparently. Did not
* know that at first. This is also where you write out any data that
* might be useful to other modes, such as the selected Graticule in
* SelectAGraticuleMode.
*
* @param bundle the Bundle to which to write data.
*/
public abstract void onSaveInstanceState(@NonNull Bundle bundle);
/**
* Called when the Activity gets onPause(). Remember, the mode object
* might not ever get this call. This is only if the Activity is
* EXPLICITLY pausing AFTER this mode was created.
*/
public abstract void pause();
/**
* Called when the Activity gets onResume(). Remember, the mode object
* might not ever get this call. This is only if the Activity is
* EXPLICITLY resuming AFTER this mode was created.
*/
public abstract void resume();
/**
* Convenience method to call {@link CentralMap#requestStock(Graticule, Calendar, int)}.
*
* @param g the Graticule (can be null for globalhashes)
* @param c the Calendar
* @param flags the {@link StockService} flags
*/
protected void requestStock(@Nullable Graticule g, @NonNull Calendar c, int flags) {
mCentralMap.getErrorBanner().animateBanner(false);
mCentralMap.requestStock(g, c, flags);
}
/**
* Called when a new Info has come in from StockService.
*
* @param info that Info
* @param nearby any nearby Infos that may have been requested (can be null)
* @param flags the request flags that were sent with it
*/
public abstract void handleInfo(Info info, @Nullable Info[] nearby, int flags);
/**
* Called when a stock lookup fails for some reason.
*
* @param reqFlags the flags used in the request
* @param responseCode the response code (won't be {@link StockService#RESPONSE_OKAY}, for obvious reasons)
*/
public abstract void handleLookupFailure(int reqFlags, int responseCode);
/**
* Called when the menu needs to be built.
*
* @param c the current Context (the mode may not be fully up by the time this is needed, and thus may not have mCentralMap)
* @param inflater a MenuInflater, for convenience
* @param menu the Menu that needs inflating.
*/
public abstract void onCreateOptionsMenu(Context c, MenuInflater inflater, Menu menu);
/**
* Called when a menu item is selected but CentralMap didn't handle it
* itself.
*
* @param item the item that got selected
* @return true if it was handled, false if not
*/
public abstract boolean onOptionsItemSelected(MenuItem item);
/**
* Called when a new Calendar comes in. The modes should update as need
* be. This should mean calling for a new Info from StockService, but
* NOT updating its own Info or concept of the current Calendar if there
* was a problem with the stock (i.e. it wasn't posted yet).
*
* @param newDate the new Calendar
*/
public abstract void changeCalendar(@NonNull Calendar newDate);
/**
* Draws a final destination point on the map given the appropriate
* Info. This also removes any old point that might've been around.
*
* @param info the new Info
*/
protected void addDestinationPoint(Info info) {
// Clear any old destination marker first.
removeDestinationPoint();
if(info == null) return;
// We need a marker! And that marker needs a title. And that title
// depends on globalhashiness and retroness.
String title;
Calendar cal = Calendar.getInstance();
Calendar infoCal = info.getCalendar();
if(DateTools.isSameDate(infoCal, cal)) {
// Non-retro hashes don't have today's date on them. They just
// have "today's [something]".
if(info.isGlobalHash()) {
title = mCentralMap.getString(R.string.marker_title_today_globalpoint);
} else {
title = mCentralMap.getString(R.string.marker_title_today_hashpoint);
}
} else if(DateTools.isTomorrow(infoCal, cal)) {
// Same with tomorrow.
if(info.isGlobalHash()) {
title = mCentralMap.getString(R.string.marker_title_tomorrow_globalpoint);
} else {
title = mCentralMap.getString(R.string.marker_title_tomorrow_hashpoint);
}
} else if(DateTools.isDayAfterTomorrow(infoCal, cal)) {
// And the day after tomorrow.
if(info.isGlobalHash()) {
title = mCentralMap.getString(R.string.marker_title_doubletomorrow_globalpoint);
} else {
title = mCentralMap.getString(R.string.marker_title_doubletomorrow_hashpoint);
}
} else {
// Anything else, however, needs a date string.
String date = DateFormat.getDateInstance(DateFormat.LONG).format(info.getDate());
if(info.isGlobalHash()) {
title = mCentralMap.getString(R.string.marker_title_retro_globalpoint, date);
} else {
title = mCentralMap.getString(R.string.marker_title_retro_hashpoint, date);
}
}
// The snippet's just the coordinates in question. Further details
// will go in the infobox.
String snippet = UnitConverter.makeFullCoordinateString(mCentralMap, info.getFinalLocation(), false, UnitConverter.OUTPUT_LONG);
// Under the current marker image, the anchor is the very bottom,
// halfway across. Presumably, that's what the default icon also
// uses, but we're not concerned with the default icon, now, are we?
mDestination = mMap.addMarker(new MarkerOptions()
.position(info.getFinalDestinationLatLng())
.icon(BitmapDescriptorFactory.fromResource(R.drawable.final_destination))
.anchor(0.5f, 1.0f)
.title(title)
.snippet(snippet));
}
/**
* Removes the destination point, if one exists.
*/
protected void removeDestinationPoint() {
if(mDestination != null) {
mDestination.remove();
mDestination = null;
}
}
/**
* Sets the title of the map Activity using a String.
*
* @param title the new title
*/
protected final void setTitle(String title) {
mCentralMap.setTitle(title);
}
/**
* Sets the title of the map Activity using a resource ID.
*
* @param resid the new title's resource ID
*/
protected final void setTitle(@StringRes int resid) {
mCentralMap.setTitle(resid);
}
/**
* Returns whether or not {@link #cleanUp()} has been called yet. If
* so, you should generally not call anything else.
*
* @return true if cleaned up, false if not
*/
public final boolean isCleanedUp() {
return mCleanedUp;
}
/**
* Returns whether or not {@link #init(Bundle)} has finished. If so,
* you probably shouldn't call init again, and are probably looking to
* call resume instead.
*
* @return true if init is complete, false if not
*/
public final boolean isInitComplete() {
return mInitComplete;
}
@Override
public String toString() {
return this.getClass().getSimpleName();
}
/**
* Gets the user's last known location as seen by CentralMap. Note that
* this may be null if the user's location isn't known yet (or if the
* user refused location permissions). Also, there's no guarantee that
* this is at all recent.
*
* @return a Location, or null
*/
@Nullable
protected Location getLastKnownLocation() {
if(mCentralMap != null)
return mCentralMap.mLastKnownLocation;
else
return null;
}
/**
* Gets the active Info the current mode is using, if any and if
* applicable.
*
* @return an Info, or null
*/
@Nullable
protected abstract Info getActiveInfo();
/**
* <p>
* Gets whether or not the user explicitly denied permissions during
* this session. Updates on this state will be sent via {@link #permissionsDenied(boolean)},
* but this should be called during {@link #init(Bundle)} to get things
* set up initially.
* </p>
*
* <p>
* Remember, just because this returns false does NOT mean permissions
* have been GRANTED; it just means permissions weren't DENIED, and most
* importantly, weren't denied YET. There is a difference. In the
* false case, for instance, the user might still be being prompted for
* permissions, in which case {@link #permissionsDenied(boolean)} might
* eventually come up with true.
* </p>
*
* @return true if permissions were denied, false if not
*/
protected boolean arePermissionsDenied() {
return mCentralMap != null && mCentralMap.mPermissionsDenied;
}
}
private class StockReceiver extends BroadcastReceiver {
private final static String DEBUG_TAG = "StockReceiver";
// This allows us to NOT blast out responses if the current mode didn't
// request it.
private Set<Long> mWaitingList;
public StockReceiver() {
mWaitingList = new HashSet<>();
}
/**
* Adds the given ID to the waiting list. If an ID comes back and it
* wasn't in the waiting list, it won't be dispatched to the modes.
*
* @param id the request ID
*/
public void addToWaitingList(long id) {
mWaitingList.add(id);
}
/**
* Removes all waiting IDs from the list
*/
public void clearWaitingList() {
// Yes, since we can have multiple IDs pointing to the same mode, we
// have to do it this way.
mWaitingList.clear();
}
@Override
public void onReceive(Context context, Intent intent) {
Log.d(DEBUG_TAG, "Stock has come in!");
// Progress goes away!
mProgress.animate().translationY(-mProgressHeight).alpha(0.0f);
Bundle bun = intent.getBundleExtra(StockService.EXTRA_STUFF);
bun.setClassLoader(getClassLoader());
// A stock result arrives! Let's get data! That oughta tell us
// whether or not we're even going to bother with it.
int reqFlags = bun.getInt(StockService.EXTRA_REQUEST_FLAGS, 0);
long reqId = bun.getLong(StockService.EXTRA_REQUEST_ID, -1);
Calendar cal = (Calendar)bun.getSerializable(StockService.EXTRA_DATE);
// Now, if the flags state this was from the alarm or somewhere else
// we weren't expecting, give up now. We don't want it.
if((reqFlags & StockService.FLAG_ALARM) != 0) return;
// Well, it's what we're looking for. What was the result? The
// default is RESPONSE_NETWORK_ERROR, as not getting a response code
// is a Bad Thing(tm).
int responseCode = bun.getInt(StockService.EXTRA_RESPONSE_CODE, StockService.RESPONSE_NETWORK_ERROR);
// Since the mode switchers wipe all requests from a given mode, all
// we need for a mode match is whether or not the item exists in the
// waiting list.
boolean modeMatches = mWaitingList.remove(reqId);
if(responseCode == StockService.RESPONSE_OKAY) {
// Hey, would you look at that, it actually worked! So, get
// the Info out of it and fire it away to the corresponding
// CentralMapMode, if applicable.
if(modeMatches) {
Info received = bun.getParcelable(StockService.EXTRA_INFO);
Parcelable[] pArr = bun.getParcelableArray(StockService.EXTRA_NEARBY_POINTS);
Info[] nearby = null;
if(pArr != null)
nearby = Arrays.copyOf(pArr, pArr.length, Info[].class);
if(received != null) {
updateLastGraticule(received);
mCurrentMode.handleInfo(received, nearby, reqFlags);
}
} else {
Log.w(DEBUG_TAG, "Request ID " + reqId + " was NOT expected by this mode, ignoring...");
}
} else {
// Make sure the mode knows what's up first.
if(modeMatches)
mCurrentMode.handleLookupFailure(reqFlags, responseCode);
if((reqFlags & StockService.FLAG_USER_INITIATED) != 0) {
// ONLY notify the user of an error if they specifically
// requested this stock.
switch(responseCode) {
case StockService.RESPONSE_NOT_POSTED_YET:
// Just in case, change the text if it's today's
// date that was requested. That's a bit clearer.
Calendar today = Calendar.getInstance();
boolean isActuallyToday = (cal != null
&& today.get(Calendar.YEAR) == cal.get(Calendar.YEAR)
&& today.get(Calendar.MONTH) == cal.get(Calendar.MONTH)
&& today.get(Calendar.DAY_OF_MONTH) == cal.get(Calendar.DAY_OF_MONTH));
mBanner.setText(getString(isActuallyToday ? R.string.error_not_yet_posted_today : R.string.error_not_yet_posted));
mBanner.setErrorStatus(ErrorBanner.Status.ERROR);
mBanner.animateBanner(true);
break;
case StockService.RESPONSE_NO_CONNECTION:
mBanner.setText(getString(R.string.error_no_connection));
mBanner.setErrorStatus(ErrorBanner.Status.ERROR);
mBanner.animateBanner(true);
break;
case StockService.RESPONSE_NETWORK_ERROR:
mBanner.setText(getString(R.string.error_server_failure));
mBanner.setErrorStatus(ErrorBanner.Status.ERROR);
mBanner.animateBanner(true);
break;
default:
break;
}
}
}
}
}
private StockReceiver mStockReceiver = new StockReceiver();
private LocationListener mLocationListener = new LocationListener() {
@Override
public void onLocationChanged(Location location) {
// New location!
mLastKnownLocation = location;
if(mCurrentMode != null) mCurrentMode.onLocationChanged(location);
}
};
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
// Load up!
if(savedInstanceState != null) {
mAlreadyDidInitialZoom = savedInstanceState.getBoolean(STATE_WAS_ALREADY_ZOOMED, false);
mSelectAGraticule = savedInstanceState.getBoolean(STATE_WAS_SELECT_A_GRATICULE, false);
mGlobalhash = savedInstanceState.getBoolean(STATE_WAS_GLOBALHASH, false);
mResolvingError = savedInstanceState.getBoolean(STATE_WAS_RESOLVING_CONNECTION_ERROR, false);
mPermissionsDenied = savedInstanceState.getBoolean(STATE_WERE_PERMISSIONS_DENIED, false);
mLastGraticule = savedInstanceState.getParcelable(STATE_LAST_GRATICULE);
mLastCalendar = (Calendar)savedInstanceState.getSerializable(STATE_LAST_CALENDAR);
// This will just get dropped right back into the mode wholesale.
mLastModeBundle = savedInstanceState.getBundle(STATE_LAST_MODE_BUNDLE);
} else if(intent != null && (intent.getAction().equals(AlarmService.START_INFO) || intent.getAction().equals(AlarmService.START_INFO_GLOBAL))) {
// savedInstanceState should override the Intent.
mLastModeBundle = new Bundle();
mLastModeBundle.putParcelable(CentralMapMode.INFO, intent.getBundleExtra(StockService.EXTRA_STUFF).getParcelable(StockService.EXTRA_INFO));
mSelectAGraticule = false;
}
setContentView(R.layout.centralmap);
// We deal with locations, so we deal with the GoogleApiClient. It'll
// connect during onStart.
mGoogleClient = new GoogleApiClient.Builder(this)
.addConnectionCallbacks(this)
.addOnConnectionFailedListener(this)
.addApi(LocationServices.API)
.build();
mBanner = (ErrorBanner)findViewById(R.id.error_banner);
mProgress = findViewById(R.id.progress_container);
// Apply nighttime mode to the progress background! Only do that if
// this is less than Lollipop, though. We can apply the color of the
// active theme directly in the resource files in Lollipop or later, but
// anything beforehand, we need to fake it.
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
if(isNightMode())
mProgress.setBackground(ContextCompat.getDrawable(this, R.drawable.progress_background_dark));
else
mProgress.setBackground(ContextCompat.getDrawable(this, R.drawable.progress_background));
}
// The progress-o-matic needs to be off-screen. And, we need to know
// how much it should shift down to become back on-screen.
mProgressHeight = getResources().getDimension(R.dimen.progress_spinner_size) + (2 * getResources().getDimension(R.dimen.double_padding));
mProgress.setTranslationY(-mProgressHeight);
mProgress.setAlpha(0.0f);
// Get a map ready. We'll know when we've got it. Oh, we'll know.
MapFragment mapFrag = (MapFragment)getFragmentManager().findFragmentById(R.id.map);
mapFrag.getMapAsync(new OnMapReadyCallback() {
@Override
public void onMapReady(GoogleMap googleMap) {
mMap = googleMap;
// I could swear you could do this in XML...
UiSettings set = mMap.getUiSettings();
// The My Location button has to go off, as we're going to have the
// infobox right around there.
set.setMyLocationButtonEnabled(false);
// Go to preferences to figure out what map type we're using.
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(CentralMap.this);
mapTypeSelected(prefs.getInt(GHDConstants.PREF_LAST_MAP_TYPE, GoogleMap.MAP_TYPE_NORMAL));
// Now, set the flag that tells everything else (especially the
// doReadyChecks method) we're ready. Then, call doReadyChecks.
// We might still be waiting on the API.
mMapIsReady = true;
doReadyChecks();
}
});
// Perform startup and cleanup work before the modes arrive.
doStartupStuff();
// Figure out where the user needs to be when this starts. Either of
// these will alter mLastModeBundle if need be. If it's a shortcut,
// mLastModeBundle is overwritten. If not, mLastModeBundle is only
// overwritten if it's null.
if(!startFromShortcut(intent)) {
startInCorrectMode();
}
// Now, we get our initial mode set up based on mSelectAGraticule. We
// do NOT init it yet; we have to wait for both the map fragment and the
// API to be ready first.
if(mSelectAGraticule)
mCurrentMode = new SelectAGraticuleMode();
else
mCurrentMode = new ExpeditionMode();
}
@Override
protected void onPause() {
// The modes should know what they need to do when pausing.
if(mCurrentMode != null)
mCurrentMode.pause();
super.onPause();
}
@Override
protected void onResume() {
super.onResume();
// Do a permissions check. If it turns out we DO have permissions, we
// can mark the denied flag as false. This covers cases where the user
// denies permission at one point, suspends the Activity, grants it some
// other way, and returns. I'm quite certain someone will make a really
// big deal out of it and claim it's a common use case if I don't catch
// this circumstance.
if(checkLocationPermissions(0, true)) mPermissionsDenied = false;
// The mode will resume itself once the client comes back in from
// onStart.
}
@Override
protected void onRestart() {
super.onRestart();
// If there's an active expedition AND it's not today, remind the user
// of this. It's not always obvious, especially given how Android won't
// reset its concept of the app being "active" in a lot of cases.
Info activeInfo = null;
if(mCurrentMode != null) activeInfo = mCurrentMode.getActiveInfo();
if(activeInfo != null && !DateTools.isSameDate(activeInfo.getCalendar(), Calendar.getInstance())) {
Toast.makeText(this, R.string.toast_not_today_reminder, Toast.LENGTH_LONG).show();
}
}
@Override
protected void onStart() {
super.onStart();
// The receiver goes on during onStart, since the modes might need it
// before onResume has a chance to kick in, thanks to the possibility of
// the API connection happening really quickly.
IntentFilter filt = new IntentFilter();
filt.addAction(StockService.ACTION_STOCK_RESULT);
registerReceiver(mStockReceiver, filt);
// Service up!
mGoogleClient.connect();
}
@Override
protected void onStop() {
// The receiver goes right off as soon as we stop.
unregisterReceiver(mStockReceiver);
// I probably want this in onPause, not onStop, but the Google API
// client disconnect hits here, not in onPause, so I'd have to keep
// track of more things to make sure I know if I need to start listening
// again on onResume or wait for the client to reconnect. And I don't
// want the client disconnecting on onPause.
stopListening();
// Service down!
mGoogleClient.disconnect();
super.onStop();
}
@Override
protected void onDestroy() {
// Make sure that mode's been cleaned up first.
mCurrentMode.cleanUp();
super.onDestroy();
}
@Override
protected void onNewIntent(Intent intent) {
// An Intent just came in! That's telling us to go off to a new
// Graticule. Since onNewIntent is only called if the Activity's
// already going, it just means we need to tell ExpeditionMode that it
// has a new Info (or tell SelectAGraticuleMode to leave first).
Bundle bun = intent.getBundleExtra(StockService.EXTRA_STUFF);
if(bun == null) return;
Info info = bun.getParcelable(StockService.EXTRA_INFO);
if(info == null) return;
// Presumably, we're ready. If the user can somehow be in this Activity
// and get the notification fired off before the ready checks commence,
// I'll need to rethink this. But for now, either switch modes or tell
// ExpeditionMode to handle a new Info.
if(mSelectAGraticule) {
// It's like exiting Select-A-Graticule Mode, but without caring
// what sort of data was in there.
mSelectAGraticule = false;
mCurrentMode.cleanUp();
mLastModeBundle = new Bundle();
mLastModeBundle.putParcelable(CentralMapMode.INFO, info);
mStockReceiver.clearWaitingList();
mCurrentMode = new ExpeditionMode();
doReadyChecks();
} else {
// Otherwise, we tell the active mode (ExpeditionMode) to update
// itself.
mCurrentMode.handleInfo(info, null, CentralMapMode.FLAG_FROM_NOTIFICATION);
}
}
@Override
protected void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
// Keep the various flags.
outState.putBoolean(STATE_WAS_ALREADY_ZOOMED, mAlreadyDidInitialZoom);
outState.putBoolean(STATE_WAS_SELECT_A_GRATICULE, mSelectAGraticule);
outState.putBoolean(STATE_WAS_GLOBALHASH, mGlobalhash);
outState.putBoolean(STATE_WAS_RESOLVING_CONNECTION_ERROR, mResolvingError);
outState.putBoolean(STATE_WERE_PERMISSIONS_DENIED, mPermissionsDenied);
// And some additional data.
outState.putParcelable(STATE_LAST_GRATICULE, mLastGraticule);
outState.putSerializable(STATE_LAST_CALENDAR, mLastCalendar);
// Also, shut down the current mode. We'll rebuild it later. Also, if
// init isn't complete yet, don't update the state.
if(mCurrentMode != null && mCurrentMode.isInitComplete()) {
mLastModeBundle = new Bundle();
mCurrentMode.onSaveInstanceState(mLastModeBundle);
}
outState.putBundle(STATE_LAST_MODE_BUNDLE, mLastModeBundle);
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
MenuInflater inflater = getMenuInflater();
// Just hand it off to the current mode, it'll know what to do.
mCurrentMode.onCreateOptionsMenu(this, inflater, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
// CentralMap should just cover the items that can always be selected no
// matter what mode we're in.
switch(item.getItemId()) {
case R.id.action_map_type: {
// The map type can be changed at any time, so it has to be
// common. To the alert dialog!
MapTypeDialogFragment frag = MapTypeDialogFragment.newInstance(this);
frag.show(getFragmentManager(), MAP_TYPE_DIALOG);
return true;
}
case R.id.action_versionhistory: {
// The version history has no real actions at all.
VersionHistoryDialogFragment frag = VersionHistoryDialogFragment.newInstance(this);
frag.show(getFragmentManager(), VERSION_HISTORY_DIALOG);
return true;
}
case R.id.action_about: {
// About is just a dialog with a view.
AboutDialogFragment frag = AboutDialogFragment.newInstance();
frag.show(getFragmentManager(), ABOUT_DIALOG);
return true;
}
case R.id.action_date: {
// The date picker is common to all modes and is best handled by
// the Activity itself.
if(mLastCalendar == null) {
// Of course, we need a date to fill in.
mLastCalendar = Calendar.getInstance();
}
GHDDatePickerDialogFragment frag = GHDDatePickerDialogFragment.newInstance(mLastCalendar);
frag.setCallback(this);
frag.show(getFragmentManager(), DATE_PICKER_DIALOG);
return true;
}
case R.id.action_whatisthis: {
// The everfamous and much-beloved "What's Geohashing?" button,
// because honestly, this IS sort of confusing if you're
// expecting something for geocaching.
Intent i = new Intent();
i.setAction(Intent.ACTION_VIEW);
i.setData(Uri.parse("http://wiki.xkcd.com/geohashing/How_it_works"));
startActivity(i);
return true;
}
case R.id.action_preferences: {
// Preferences! To the Preferencemobile!
Intent i = new Intent(this, PreferencesScreen.class);
startActivity(i);
return true;
}
default:
return mCurrentMode.onOptionsItemSelected(item);
}
}
@SuppressLint("CommitPrefEdits")
private void doStartupStuff() {
// This handles all the oddities that need to be covered at startup
// time, including cleaning up old preferences that have been replaced
// or otherwise changed, starting the stock alarm service if it should
// be up, and throwing up the version history dialog if it's a new
// version.
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
SharedPreferences.Editor edit = prefs.edit();
// Let's start with the stock alarm service.
Intent i = new Intent(this, AlarmService.class);
if(prefs.getBoolean(GHDConstants.PREF_STOCK_ALARM, false)) {
// Alarm gets set! Fire it up!
i.setAction(AlarmService.STOCK_ALARM_ON);
} else {
// No alarm! Off it goes!
i.setAction(AlarmService.STOCK_ALARM_OFF);
}
WakefulIntentService.sendWakefulWork(this, i);
// Now for preference cleanup. Unfortunately, this section will only
// get bigger with time, as I can't guarantee what version the user
// might've come from. The version from which the user might've come.
// The Infobox is now controlled by a boolean, not a string.
if(prefs.contains("InfoBoxSize")) {
if(!prefs.contains(GHDConstants.PREF_INFOBOX)) {
String size;
try {
size = prefs.getString("InfoBoxSize", "None");
} catch (ClassCastException cce) {
size = "Off";
}
edit.putBoolean(GHDConstants.PREF_INFOBOX, size.equals("None"));
}
edit.remove("InfoBoxSize");
}
// These prefs either don't exist any more or we found better ways to
// deal with them.
edit.remove("DefaultLatitude")
.remove("DefaultLongitude")
.remove("GlobalhashMode")
.remove("RememberGraticule")
.remove("ClosestOn")
.remove("AlwaysToday")
.remove("ClosenessReported");
// Anything edit-worthy we just did needs to be committed.
edit.commit();
// We still have that prefs object. Let's see if we've got a newer
// version than what we last saw.
int lastVersion = prefs.getInt(GHDConstants.PREF_LAST_SEEN_VERSION, 0);
int curVersion = -1;
try {
curVersion = getPackageManager().getPackageInfo(getPackageName(), 0).versionCode;
} catch (PackageManager.NameNotFoundException nnfe) {
// Since this is OUR OWN PACKAGE NAME, this better work.
}
Log.d(DEBUG_TAG, "We are version " + curVersion + ", we last reported version history on version " + lastVersion);
if(lastVersion < curVersion) {
// Aha! We're newer! Now, let's see if there's a new version to
// display. That is, if the first entry in version history is newer
// than the last-seen version.
ArrayList<VersionHistoryParser.VersionEntry> entries = new ArrayList<>();
try {
entries = VersionHistoryParser.parseVersionHistory(this);
} catch(XmlPullParserException xppe) {
// You get NOTHING!
}
if(entries.isEmpty()) {
Log.w(DEBUG_TAG, "Couldn't parse version history, not displaying anything.");
} else {
Log.d(DEBUG_TAG, "Newest version with an entry is " + entries.get(0).versionCode);
if(entries.get(0).versionCode > lastVersion) {
VersionHistoryDialogFragment frag = VersionHistoryDialogFragment.newInstance(entries);
frag.show(getFragmentManager(), VERSION_HISTORY_DIALOG);
}
}
}
// In any case, update the version.
edit.putInt(GHDConstants.PREF_LAST_SEEN_VERSION, curVersion);
edit.apply();
// Then, tell the BackupManager to do its thing.
BackupManager bm = new BackupManager(this);
bm.dataChanged();
}
/**
* Requests a stock. This'll come back and be handled appropriately by
* CentralMap, which more or less amounts to handling the ErrorBanner and
* sending the result off to the active CentralMapMode.
*
* @param g the Graticule (can be null for globalhashes)
* @param cal the date
* @param flags the {@link StockService} flags
*/
private void requestStock(@Nullable Graticule g, @NonNull Calendar cal, int flags) {
// Progress shows up!
mProgress.animate().translationY(0.0f).alpha(1.0f);
// As a request ID, we'll use the current date, because why not?
long date = cal.getTimeInMillis();
Intent i = new Intent(this, StockService.class)
.putExtra(StockService.EXTRA_DATE, cal)
.putExtra(StockService.EXTRA_GRATICULE, g)
.putExtra(StockService.EXTRA_REQUEST_ID, date)
.putExtra(StockService.EXTRA_REQUEST_FLAGS, flags);
mStockReceiver.addToWaitingList(date);
WakefulIntentService.sendWakefulWork(this, i);
}
@Override
public void onConnected(Bundle bundle) {
// We're connected! Start listening for updates! The modes will get
// their updates through us.
startListening();
if(!isFinishing()) {
doReadyChecks();
}
}
@Override
public void onConnectionSuspended(int i) {
// Since the location API doesn't appear to connect back to the network,
// I'm not sure I need to do anything special here. I'm not even
// entirely convinced the connection CAN become suspended after it's
// made unless things are completely hosed. At the very least, though,
// I can stop listening.
stopListening();
}
/**
* Tells Select-A-Graticule to start.
*/
public void enterSelectAGraticuleMode() {
if(mSelectAGraticule) return;
mSelectAGraticule = true;
// We can at least get a starter Graticule for Select-A-Graticule, if
// Expedition had one yet.
mLastModeBundle = new Bundle();
mCurrentMode.onSaveInstanceState(mLastModeBundle);
mCurrentMode.cleanUp();
mStockReceiver.clearWaitingList();
mCurrentMode = new SelectAGraticuleMode();
doReadyChecks();
}
/**
* Tells Select-A-Graticule mode to exit, and does whatever's needed to make
* that work. I could sure use a better way to do this other than making
* the method public...
*/
public void exitSelectAGraticuleMode() {
if(!mSelectAGraticule) return;
mSelectAGraticule = false;
// The result can be retrieved from the Bundle and shoved right into
// ExpeditionMode via doReadyChecks.
mLastModeBundle = new Bundle();
mCurrentMode.onSaveInstanceState(mLastModeBundle);
mCurrentMode.cleanUp();
mStockReceiver.clearWaitingList();
mCurrentMode = new ExpeditionMode();
doReadyChecks();
}
@Override
public void onBackPressed() {
// If we're in Select-A-Graticule, pressing back will send us back to
// expedition mode. This seems obvious, especially when the default
// implementation will close the graticule fragment anyway when the back
// stack is popped, but we also need to do the other stuff like change
// the menu back, stop the tap-the-map selections, etc. Also, I really
// wish there were a better way to do this that didn't require this
// Activity keeping track of things.
if(mCurrentMode instanceof SelectAGraticuleMode)
exitSelectAGraticuleMode();
else
super.onBackPressed();
}
private boolean isReadyToGo() {
return !mCurrentMode.isCleanedUp() && mMapIsReady && mGoogleClient != null && mGoogleClient.isConnected();
}
private void doReadyChecks() {
// This should be called any time the Google API client or MapFragment
// become ready. It'll check to see if both are up, starting the
// current mode when so.
if(isReadyToGo()) {
if(mCurrentMode.isInitComplete()) {
mCurrentMode.resume();
} else {
mCurrentMode.setMap(mMap);
mCurrentMode.setCentralMap(this);
mCurrentMode.init(mLastModeBundle);
}
if(mLastKnownLocation != null && LocationUtil.isLocationNewEnough(mLastKnownLocation))
mCurrentMode.onLocationChanged(mLastKnownLocation);
invalidateOptionsMenu();
// Now, read all the KnownLocations and put them on the map. Remove
// anything we had before.
if(mKnownLocationMarkers != null)
for(Marker m : mKnownLocationMarkers)
m.remove();
mKnownLocationMarkers = new LinkedList<>();
// Now, ONLY if prefs say so...
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
if(prefs.getBoolean(GHDConstants.PREF_SHOW_KNOWN_LOCATIONS, true)) {
for(KnownLocation kl : KnownLocation.getAllKnownLocations(this)) {
// No snippet this time; there's nothing to do with the marker
// other than show its name.
Marker mark = mMap.addMarker(kl.makeMarker(this));
mKnownLocationMarkers.add(mark);
}
}
}
}
/**
* Gets the {@link ErrorBanner} we currently hold. This is mostly for the
* {@link CentralMapMode} classes.
*
* @return the current ErrorBanner
*/
public ErrorBanner getErrorBanner() {
return mBanner;
}
/**
* Gets the {@link GoogleApiClient} we currently hold. There's no guarantee
* it's connected at this point, so be careful.
*
* @return the current GoogleApiClient
*/
public GoogleApiClient getGoogleClient() {
return mGoogleClient;
}
@Override
public void datePicked(Calendar picked) {
// Calendar!
mLastCalendar = picked;
mCurrentMode.changeCalendar(mLastCalendar);
}
private void startListening() {
if(checkLocationPermissions(LOCATION_PERMISSION_REQUEST)) {
LocationRequest lRequest = LocationRequest.create();
lRequest.setInterval(1000);
lRequest.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);
// Stupid Android Studio annotator and how it can't tell I've
// requested permissions already...
try {
LocationServices.FusedLocationApi.requestLocationUpdates(mGoogleClient, lRequest, mLocationListener);
// As per the 8.3.0 services, setMyLocationEnabled is a permissions-
// locked method. Which, to be honest, is a good thing, really, it
// didn't make much sense that you could turn that on without
// permissions before.
mMap.setMyLocationEnabled(true);
} catch (SecurityException se) {
// We MUST have permissions at this point, given the call to
// checkLocationPermissions should have returned false if not.
// If we're in some situation where we STILL got a
// SecurityException, calling it again won't help, because
// that'll just bring us back here in an endless loop. So, we
// ignore it.
}
}
}
private void stopListening() {
if(mGoogleClient != null && checkLocationPermissions(LOCATION_PERMISSION_REQUEST, true)) {
LocationServices.FusedLocationApi.removeLocationUpdates(mGoogleClient, mLocationListener);
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
if(permissions.length <= 0 || grantResults.length <= 0)
return;
// CentralMap will generally be handling location permissions. So...
if(grantResults[0] == PackageManager.PERMISSION_DENIED) {
// Whoops. We're sunk. Go to the permission failure dialog thing.
Bundle args = new Bundle();
args.putInt(PermissionDeniedDialogFragment.TITLE, R.string.title_permission_location);
args.putInt(PermissionDeniedDialogFragment.MESSAGE, R.string.explain_permission_location);
PermissionDeniedDialogFragment frag = new PermissionDeniedDialogFragment();
frag.setArguments(args);
frag.show(getFragmentManager(), "PermissionDeniedDialog");
mPermissionsDenied = true;
} else {
// Thankfully, we don't need to ask for forgiveness, as we've
// got permissions right here!
startListening();
mPermissionsDenied = false;
}
if(mCurrentMode != null) mCurrentMode.permissionsDenied(mPermissionsDenied);
}
private void updateLastGraticule(@NonNull Info info) {
// This'll just stash the last Graticule away in preferences so we can
// start with the last-used one if preferences demand it as such.
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
SharedPreferences.Editor edit = prefs.edit();
edit.putBoolean(GHDConstants.PREF_DEFAULT_GRATICULE_GLOBALHASH, info.isGlobalHash());
Graticule g = info.getGraticule();
if(g != null) {
edit.putString(GHDConstants.PREF_DEFAULT_GRATICULE_LATITUDE, g.getLatitudeString(true));
edit.putString(GHDConstants.PREF_DEFAULT_GRATICULE_LONGITUDE, g.getLongitudeString(true));
}
edit.apply();
BackupManager bm = new BackupManager(this);
bm.dataChanged();
}
private boolean startFromShortcut(@Nullable Intent intent) {
// I somehow feel this could be made more efficient...
if(intent == null) return false;
String action = intent.getAction();
if(action == null) return false;
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
String lastLat = prefs.getString(GHDConstants.PREF_DEFAULT_GRATICULE_LATITUDE, "INVALID");
String lastLon = prefs.getString(GHDConstants.PREF_DEFAULT_GRATICULE_LONGITUDE, "INVALID");
boolean globalHash = prefs.getBoolean(GHDConstants.PREF_DEFAULT_GRATICULE_GLOBALHASH, false);
Graticule g;
try {
g = new Graticule(lastLat, lastLon);
} catch(Exception e) {
// If a problem popped up, we just assume there was no
// actual graticule data.
g = null;
}
// In all cases, if this IS a shortcut, we're overwriting
// mLastModeBundle, no matter what it was. Obviously, if it isn't a
// shortcut, leave mLastModeBundle alone.
switch(action) {
case ACTION_START_CLOSEST_HASHPOINT:
mLastModeBundle = new Bundle();
doStartupClosest();
break;
case ACTION_START_GRATICULE_PICKER:
mLastModeBundle = new Bundle();
doStartupPicker(g, globalHash);
break;
case ACTION_START_LAST_USED:
mLastModeBundle = new Bundle();
doStartupLastUsed(g, globalHash);
break;
default:
return false;
}
return true;
}
private void startInCorrectMode() {
// If at this point we don't have any mode bundle, we're going to the
// prefs to figure out where we start.
if(mLastModeBundle == null) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
String startup = prefs.getString(GHDConstants.PREF_STARTUP_BEHAVIOR, GHDConstants.PREFVAL_STARTUP_CLOSEST);
mLastModeBundle = new Bundle();
if(startup.equals(GHDConstants.PREFVAL_STARTUP_CLOSEST)) {
// Closest point. The default.
doStartupClosest();
} else {
// The other cases, we need to know the last-known graticule and
// globalhashiness.
String lastLat = prefs.getString(GHDConstants.PREF_DEFAULT_GRATICULE_LATITUDE, "INVALID");
String lastLon = prefs.getString(GHDConstants.PREF_DEFAULT_GRATICULE_LONGITUDE, "INVALID");
boolean globalHash = prefs.getBoolean(GHDConstants.PREF_DEFAULT_GRATICULE_GLOBALHASH, false);
Graticule g;
try {
g = new Graticule(lastLat, lastLon);
} catch(Exception e) {
// If a problem popped up, we just assume there was no
// actual graticule data.
g = null;
}
if(startup.equals(GHDConstants.PREFVAL_STARTUP_LAST_USED)) {
doStartupLastUsed(g, globalHash);
} else if(startup.equals(GHDConstants.PREFVAL_STARTUP_PICKER)) {
// The graticule picker. In theory, we MAY have a graticule to
// start with. With which to start.
doStartupPicker(g, globalHash);
}
}
}
}
private void doStartupClosest() {
mLastModeBundle.putBoolean(ExpeditionMode.DO_INITIAL_START, true);
mSelectAGraticule = false;
}
private void doStartupLastUsed(@Nullable Graticule g, boolean globalHash) {
mSelectAGraticule = false;
// Last-used point. Now, if we don't HAVE any such data, we
// fall back to closest point behavior.
if(g == null && !globalHash) {
// Well, poop.
mLastModeBundle.putBoolean(ExpeditionMode.DO_INITIAL_START, true);
} else {
// If we've got something, yay! Add in data as appropriate.
// Start with today.
mLastModeBundle.putSerializable(CentralMapMode.CALENDAR, Calendar.getInstance());
if(globalHash)
mLastModeBundle.putBoolean(CentralMapMode.GLOBALHASH, true);
if(g != null)
mLastModeBundle.putParcelable(CentralMapMode.GRATICULE, g);
}
}
private void doStartupPicker(@Nullable Graticule g, boolean globalHash) {
mLastModeBundle.putBoolean(CentralMapMode.GLOBALHASH, globalHash);
if(g != null)
mLastModeBundle.putParcelable(CentralMapMode.GRATICULE, g);
mLastModeBundle.putSerializable(CentralMapMode.CALENDAR, Calendar.getInstance());
mSelectAGraticule = true;
}
}